Skip to content

14 类型编程综合实战一

我们学会了 6 个类型体操的套路,各种高级类型都能写出来,也知道了类型体操的意义(类型之间有关联的时候必须要类型编程,用类型编程能做到更精准的类型提示和检查),但是做的练习还是不够多。

前面的案例更多是用于讲某个套路的,这节开始我们做一些比较综合的案例。

KebabCaseToCamelCase

常用的变量命名规范有两种,一种是 KebabCase,也就是 aaa-bbb-ccc 这种中划线分割的风格,另一种是 CamelCase, 也就是 aaaBbbCcc 这种除第一个单词外首字母大写的风格。

如果想实现 KebabCase 到 CamelCase 的转换,该怎么做呢?

比如从 guang-and-dong 转换成 guangAndDong。

这种明显是要做字符串字面量类型的提取和构造,并且因为单词数量不确定,要递归地处理。

所以是这样写:

ts
type KebabCaseToCamelCase<Str extends string> = Str extends `${infer Item}-${infer Rest}`
  ? `${Item}${KebabCaseToCamelCase<Capitalize<Rest>>}`
  : Str;

类型参数 Str 是待处理的字符串类型,约束为 string。

通过模式匹配提取 Str 中 - 分隔的两部分,前面的部分放到 infer 声明的局部变量 Item 里,后面的放到 infer 声明的局部变量 Rest 里。

提取的第一个单词不大写,后面的字符串首字母大写,然后递归的这样处理,然后也就是 `${Item}${KebabCaseToCamelCase`。

如果模式匹配不满足,就返回 Str。

这样就完成了 KebabCase 到 CamelCase 的转换:

image

试一下

那反过来怎么转换呢?我们再实现下 CamelCase 到 KebabCase 的转换:

CamelCaseToKebabCase

同样是对字符串字面量类型的提取和构造,也需要递归处理,但是 CamelCase 没有 - 这种分割符,那怎么分割呢?

可以判断字母的大小写,用大写字母分割。

也就是这样:

ts
type CamelCaseToKebabCase<Str extends string> = Str extends `${infer First}${infer Rest}`
  ? First extends Lowercase<First>
    ? `${First}${CamelCaseToKebabCase<Rest>}`
    : `-${Lowercase<First>}${CamelCaseToKebabCase<Rest>}`
  : Str;

类型参数 Str 为待处理的字符串类型。

通过模式匹配提取首个字符到 infer 声明的局部变量 First,剩下的放到 Rest。

判断下当前字符是否是小写,如果是的话就不需要转换,递归处理后续字符,也就是 `${First}${CamelCaseToKebabCase}`。

如果是大写,那就找到了要分割的地方,转为 - 分割的形式,然后把 First 小写,后面的字符串递归的处理,也就是 `-${Lowercase}${CamelCaseToKebabCase}`。

如果模式匹配不满足,就返回 Str。

这样就完成了 CamelCase 到 KebabCase 的转换:

image

试一下

做了两个字符串类型的练习,再来做个数组类型的:

Chunk

希望实现这样一个类型:

对数组做分组,比如 1、2、3、4、5 的数组,每两个为 1 组,那就可以分为 1、2 和 3、4 以及 5 这三个 Chunk。

这明显是对数组类型的提取和构造,元素数量不确定,需要递归的处理,并且还需要通过构造出的数组的 length 来作为 chunk 拆分的标志。

所以这个类型逻辑这么写:

ts
type Chunk<
  Arr extends unknown[],
  ItemLen extends number,
  CurItem extends unknown[] = [],
  Res extends unknown[] = []
> = Arr extends [infer First, ...infer Rest]
  ? CurItem["length"] extends ItemLen
    ? Chunk<Rest, ItemLen, [First], [...Res, CurItem]>
    : Chunk<Rest, ItemLen, [...CurItem, First], Res>
  : [...Res, CurItem];

类型参数 Arr 为待处理的数组类型,约束为 unknown。类型参数 ItemLen 是每个分组的长度。

后两个类型参数是用于保存中间结果的:类型参数 CurItem 是当前的分组,默认值 [],类型参数 Res 是结果数组,默认值 []。

通过模式匹配提取 Arr 中的首个元素到 infer 声明的局部变量 First 里,剩下的放到 Rest 里。

通过 CurItem 的 length 判断是否到了每个分组要求的长度 ItemLen:

如果到了,就把 CurItem 加到当前结果 Res 里,也就是 […Res, CurItem],然后开启一个新分组,也就是 [First]。

如果没到,那就继续构造当前分组,也就是 […CurItem, First],当前结果不变,也就是 Res。

这样递归的处理,直到不满足模式匹配,那就把当前 CurItem 也放到结果里返回,也就是 […Res, CurItem]。

这样就完成了根据长度对数组分组的功能:

image

试一下

字符串类型、数组类型都做了一些练习,接下来再做个索引类型的:

TupleToNestedObject

我们希望实现这样一个功能:

根据数组类型,比如 ['a', 'b', 'c'] 的元组类型,再加上值的类型 'xxx',构造出这样的索引类型:

ts
{
  a: {
    b: {
      c: "xxx";
    }
  }
}

这个依然是提取、构造、递归,只不过是对数组类型做提取,构造索引类型,然后递归的这样一层层处理。

也就是这样的:

ts
type TupleToNestedObject<Tuple extends unknown[], Value> = Tuple extends [infer First, ...infer Rest]
  ? {
      [Key in First as Key extends keyof any ? Key : never]: Rest extends unknown[]
        ? TupleToNestedObject<Rest, Value>
        : Value;
    }
  : Value;

类型参数 Tuple 为待处理的元组类型,元素类型任意,约束为 unknown[]。类型参数 Value 为值的类型。

通过模式匹配提取首个元素到 infer 声明的局部变量 First,剩下的放到 infer 声明的局部变量 Rest。

用提取出来的 First 作为 Key 构造新的索引类型,也就是 Key in First,值的类型为 Value,如果 Rest 还有元素的话就递归的构造下一层。

为什么后面还有个 as Key extends keyof any ? Key : never 的重映射呢?

因为比如 null、undefined 等类型是不能作为索引类型的 key 的,就需要做下过滤,如果是这些类型,就返回 never,否则返回当前 Key。

这里的 keyof any 在内置的高级类型那节也有讲到,就是取当前支持索引支持哪些类型的:

image

如果提取不出元素,那就构造结束了,返回 Value。

这样就实现了根据元组构造索引类型的功能:

image

当传入 number 时:

image

当传入 undefined 时:

image

试一下

我们再来练习下内置的高级类型,我们对这块的练习比较少:

PartialObjectPropByKeys

我们想实现这样一个功能:

把一个索引类型的某些 Key 转为 可选的,其余的 Key 不变,

比如

ts
interface Dong {
  name: string;
  age: number;
  address: string;
}

把 name 和 age 变为可选之后就是这样的:

ts
interface Dong2 {
  name?: string;
  age?: number;
  address: string;
}

这样的类型逻辑很容易想到是用映射类型的语法构造一个新的类型。

但是我们这里要求只用内置的高级类型来实现。

那要怎么做呢?

内置的高级类型里有很多处理映射类型的,比如 Pick 可以根据某些 Key 构造一个新的索引类型,Omit 可以删除某些 Key 构造一个新的索引类型,Partial 可以把索引类型的所有 Key 转为可选。

综合运用这些内置的高级类型就能实现我们的需求:

我们先把 name 和 age 这俩 Key 摘出来构造一个新的索引类型:

image

然后把剩下的 Key 摘出来构造一个新的索引类型:

image

把第一个索引类型转为 Partial,第二个索引类型不变,然后取交叉类型。

交叉类型会把同类型做合并,不同类型舍弃,所以结果就是我们需要的索引类型。

ts
type PartialObjectPropByKeys<Obj extends Record<string, any>, Key extends keyof any> = Partial<
  Pick<Obj, Extract<keyof Obj, Key>>
> &
  Omit<Obj, Key>;

类型参数 Obj 为待处理的索引类型,约束为 Record<string, any>。

类型参数 Key 为要转为可选的索引,那么类型自然是 string、number、symbol 中的类型,通过 keyof any 来约束更好一些。默认值是 Obj 的索引。

keyof any 是动态返回索引支持的类型,如果开启了 keyOfStringsOnly 的编译选项,那么返回的就是 string,否则就是 string | number | symbol 的联合类型,这样动态取的方式比写死更好。

Extract 是用于从 Obj 的所有索引 keyof Obj 里取出 Key 对应的索引的,这样能过滤掉一些 Obj 没有的索引。

image

从 Obj 中 Pick 出 Key 对应的索引构造成新的索引类型并转为 Partial 的,也就是 Partial<Pick<Obj,Extract<keyof Obj, Key>>>,其余的 Key 构造一个新的索引类型,也就是 Omit<Obj,Key>。然后两者取交叉就是我们需要的索引类型:

image

为啥这里没计算出最终的类型呢?

因为 ts 的类型只有在用到的的时候才会去计算,这里并不会去做计算。我们可以再做一层映射,当构造新的索引类型的时候,就会做计算了:

ts
type Copy<Obj extends Record<string, any>> = {
  [Key in keyof Obj]: Obj[Key];
};

type PartialObjectPropByKeys<Obj extends Record<string, any>, Key extends keyof any = keyof Obj> = Copy<
  Partial<Pick<Obj, Extract<keyof Obj, Key>>> & Omit<Obj, Key>
>;

这里的 Copy 就是通过映射类型的语法构造新的索引类型,key 和 value 都不变。

这样就会计算出最终的索引类型:

image

试一下

当然,这里的 Copy 也可以不加,并不影响功能。

总结

我们学完了类型编程的套路,也知道了类型编程的意义(类型有关联的时候必须用类型编程,类型编程可以实现更精准的类型提示和检查),但是做的综合一些的案例还是少,这节就各种类型的类型编程都做了一遍。

包括字符串类型、数组类型、索引类型的构造、提取,都涉及到了递归,也对内置的高级类型做了练习。

这一节的类型练下来,相信你会对类型编程会更加得心应手了。

本文案例的合并